Home PDF MD LaTeX Github
Reading Options

Time Complexity

Time Complexity

Introduction

Time complexity is a useful tool to measure how efficient a computer algorithm is. This is necessary information as computer programmers typically wish to employ the most efficient algorithms to reduce operating costs, running time, electricity usage, etc. While two algorithms may do the same thing at the end of the day, the one which is more efficient will be typically preferred.

Notation

There are three main ways to measure time complexity: Big-O, Big-Omega, and Big-Theta.

Big-Theta is an exact bound on the runtime of an algorithm. It states that the given algorithm will run no slower and no faster than the given bound.

Big-Omega is a lower bound on the runtime of an algorithm. It states that the algorithm will run no faster than the given bound.

Big-O is the last of the three and is an upper bound on the runtime of an algorithm. It states that the algorithm will run no slower than the bound.

Typically, only Big-O is used to express the runtime of an algorithm. Such usage is often inaccurate and it can be difficult to keep track of the three measures. For all intents and purposes, Big-O is treated as a general worst-case bound.

It is important to note that Big-O, Big-Theta, and Big-Omega typically represent bounds on the worst case runtime of an algorithm. The best time case of e.g. a recursive algorithm is typically constant time, since a recursive function always needs a base case. However, it is nonsensical to use this to represent the runtime of the algorithm. Thus, the worst case is considered.

Asymptotic Behavior

Big-O is only concerned with the asymptotic behavior of a function. That is to say that Big-O of a function f(n) (also written O(f(n)) ) considers the behavior as n, the input to the function, is relatively large.

When finding the runtime complexity of an algorithm, it is typically unnecessary to consider small inputs. Given a small enough list, any search function will perform relatively similarly. It’s only at the big cases where the runtime complexities begin to diverge.

Mathematical Description

Here, only Big-O is considered, although it is trivial to come up with the descriptions of Big-Theta and Big-Omega.

The following statement: f(n) is O(g(n)) can be expressed mathematically as such:

f(n) is O(g(n)) if and only if for all n > N, there is some k such that f(n) <= kg(n). Choosing appropriate N and k can allow us to directly prove if some function is Big-O of some other function.

f(n) is O(g(n)) really means that at some sufficiently large input, f(n) is always less than or equal to g(n) for the same input.

Function Ranking

By using the above definition of Big-O, some common functions can be ranked such that each function is Big-O of the next function:

1 < log(n) < n < nlog(n) < n^2 < n^99 < 2^n < 99^n < n!

In this ranking there are a couple of different types of functions, all of which are important to know. 1 is the simplest and the fastest function. It is known as “constant time”. An algorithm that is O(1) will always run in the same amount of time, no matter how large its input is.

The logarithmic function, log(n) is the next fastest. This is the runtime complexity of e.g. binary search.

nlog(n) is known as log-linear and is the next step up from logarithmic. Merge sort is a common example of a log-linear algorithm.

n^2, n^99, and other powers of n are known as polynomial time. It is very easy to develop a polynomial-time algorithm as it commonly arises from nested loops.

2^n, 99^n, etc. are called exponential functions. Typically, an exponential time algorithm is unwanted as it is typically very inefficient for its task. It is very easy to devise a graph-coloring algorithm in exponential time.

The last common time complexity is factorial time, or n!. Algorithms running in factorial time are abysmally slow and entirely unwanted. BOGO sort is a good example of a factorial time algorithm.

Algorithm Runtime Complexity

It is clear that the fastest algorithm is typically the most desirable for any task, so it is important to be able to determine the runtime complexity of any given algorithm.

Constant Time

def f(n):
    return n + 1

The above algorithm is necessarily constant time, since the addition operation will complete in the same amount of time, no matter how large the input is. However, this requires a bit of extra work to write out. It is assumed that function calling and teardown are constant time operations, so they must take time k together. Similarly, it is assumed that addition is a constant time operation, so it must take time a. In order to determine the runtime complexity of the algorithm, we must sum the time it takes to complete each portion. In this case, the algorithm is O(k + a). Since k and a are both constants, we can replace them with a single constant K. Now, the algorithm is O(K). To determine the final runtime complexity, all constants must be dropped. In this case, the algorithm becomes O(1), or constant time as per above.

def f(n):
    for i in 0..5:
        n += 1

The above algorithm now includes a for-loop. It is known that call and teardown are constant time operations, taking time k. Now the loop must be considered. The loop will iterate 5 times, and add 1 to the input each time. Since addition is a constant-time operation, it takes 5a time to complete this loop. So, the complexity is O(k + 5a). Since 5 is a constant, it can be combined with k and a such that we have O(K). Now, dropping the constant leaves O(1), another constant time algorithm.

Linear Time

def f(l: list):
    for i in 0..l.length:
        n[i] += 1

This algorithm just adds 1 to every element in the input list l. It is clear now that this algorithm scales with the length of l, which we will call n. Now, we must perform the same analysis as before. Call and teardown are k time, and addition is a time. However, this algorithm iterates over the entire length of the list. Each iteration will take j time and there are n iterations, so it takes jn time to iterate. All together, the function is O(jn + k + a). Once again, constants can be combined and then dropped, so we end up with O(n + 1) time. In this case, the time complexity is comprised of two functions. Since n grows much faster than 1 (which doesn’t grow at all), it is fair to drop the slower term so that the time complexity becomes O(n), or linear time. It is valid to ignore slower terms since, as per above, Big-O is only concerned with asymptotic behavior. For large inputs, n is much larger than 1, so the constant term is insignificant.

Polynomial Time

def f(l: list):
    for i in 0..l.length:
        for j in 0..l.length:
            l[i] += 1

This algorithm is a convoluted and inefficient way to add n, the length of l, to every element in l. Call and teardown require time k. Now, the first loop will iterate n times. Inside of it is another loop that iterates another n times. Addition requires time a, so the inside loop finishes in an time. The outer loop will then require ann time, or an^2 time. Now, combining the terms gives O(k + an^2). Dropping the coefficients leaves O(n^2 + 1). Again, the slower terms can be ignored, so the algorithm is O(n^2). Now we can see that this algorithm runs in polynomial time. For what it does, this algorithm is very inefficient and it is trivial to rewrite into a linear time algorithm. This analysis helps to show exactly how slow the algorithm is.

Recursive Algorithms

Recursive functions are described using the recursive relation. It takes the form of a base case (or however base cases are necessary) as well as a recursive function. The following is the mathematical form:

T(1) = f(n)
T(n) = bT(d(n)) + h(n)

Here, T(1) is the base case and requires f(n) time. b is the branching factor, i.e. how many times the recursion is called during each run. d(n) defines how the input is decreased every run, and h(n) is the amount of time it takes to complete each run. Now, consider the following algorithm:

def f(n):
    if n == 0 or n == 1:
        return 1
    return n * f(n - 1)

The algorithm is a simple recursive definition of the factorial function. Now, let’s write the recursive relation:

The base cases are given and fairly simple: at n = 0 and n = 1, the function takes only constant time k to run, since it is only returning a constant. So, T(0) = T(1) = k.

Since f(n) is called once inside itself (called as f(n - 1) ), the branching factor b is 1.

Each subsequent recursive call decreases the input by 1, as evidenced by f(n - 1), so the decreasing function d(n) is n - 1.

Finally, the algorithm uses only a multiplication outside of the recursive call, which is a constant time operation in time m.

Putting this all together, the recursive relation is as follows:

T(0) = k
T(1) = k
T(n) = T(n - 1) + m

From here, the runtime complexity can be found by expanding the recursive relation as follows:

T(n) = T(n - 1) + m
     = (T(n - 2) + m) + m
     = ((T(n - 3) + m) + m) + m
     = T(n - 3) + 3m

Each expansion represents increasing the recursive depth by 1. Let’s call the current recursive depth r. We can trivially substitute this in to get a general form of the recursive relation like so:

T(n) = T(n - r) + rm

To solve for the time complexity, we need to get to the base case. This happens when n - r = 1, so r = n - 1. Substituting:

T(n) = T(1) + m(n - 1)
     = k + mn - m

Combining and dropping all constants leaves us with O(n + 1), which is essentially O(n) since n grows much faster than 1. This means that this recursive factorial algorithm is linear time.

Logarithmic Time Recursive Algorithms

The analysis from before will apply exactly the same to the following algorithm:

def f(l: list, s):
    if l[0] == s: return 0
    middle = floor(l.length / 2)
    if l[middle] == s:
        return middle
    else if l[middle] < s:
        return f(l[middle + 1:], s)
    else if l[middle] > s:
        return f(l[:middle - 1], s)

This algorithm is an implementation of binary search. It requires that the input list is sorted low to high and that the element that is being searched for exists in the list. This analysis will only consider this case, as any other case will be considered a bug.

We can now begin to write our recursive relation:

T(1) = k
T(n) = T(n / 2) + q

Here, the base case is fairly obvious and runs in constant time. Assuming the list splicing is a constant time operation and the if-statements are also, we can call all these times q. Since the function will only ever do one recursive call each run, the branching factor is 1. This can be seen since only one of the if statements will ever run, and we are only considering the worst case. The decreasing function can be seen as n / 2 since each recursive call will input either the upper or lower half of the list.

Now, we can begin to unroll the relation with unroll level r:

T(n) = T(n / 2) + q
     = T(n / 4) + q + q
     = T(n / 8) + q + q + q
     = T(n / 2^r) + qr

The base case is reached when n / 2^r = 1, so r = log2(n). Substituting:

T(n) = T(1) + qlog2(n)
     = k + qlog2(n)

Using properties of the logarithm we know that log2(n) = log(n)/log(2). Since 1/log(2) is a constant, it can be pulled out and combined with the other constants so that the algorithm is O(K + log(n)). In this case, log(n) still grows much faster than the constant K, so the algorithm is O(log(n)), or logarithmic time. Below is an approximate graph of linear time and logarithmic time.

 ^ time
 |     /
 |    /
 |   /   ---/
 |  / --/
 | /-/
 |/
-+-----------> n
 |

Since the linear curve is always above the logarithmic curve, a linear time algorithm will take more time to run than a logarithmic time algorithm for a similar sized input. This proves that the binary search algorithm is much faster than linear search for large enough inputs, provided that the conditions are met.

This same analysis can be applied for any recursive algorithm, although the algebra required to solve for the final runtime complexity may differ.

Conclusion

Time complexity is a powerful tool to rate the how efficient an algorithm may be. However, it is important to note that runtime complexity is not the entire picture when it comes to real time. This abstraction totally ignores things like the time and space it takes to call and destroy functions, which is especially important when looking at recursive algorithms. Typically, the algorithm with a faster runtime complexity is the one which should be used, although it is necessary to way the pros and cons of each algorithm. Merge sort, for instance, requires best and worst case time of nlog(n), though it requires creating and destroying many sub-lists, which may take a fair bit of memory. Other sorts such as quick sort may have a worst case performance of n^2, though they operate in-place and typically run at around nlog(n) speed, much like merge sort.

It is also necessary to note that oftentimes, developing a slow algorithm is very simple while devising a faster algorithm can be much more difficult and, in some cases, may be impossible.

Big-O and time complexity are essential tools to have as a programmer and computer scientist, though sometimes it requires a little extra consideration to determine which algorithm is best suited for a given scenario.